Skip to content

feat(web): wire Studio Act on findings to durable video-to-actions (WDK v1) - #1507

Merged
groupthinking merged 1 commit into
mainfrom
feat/workflow-video-to-actions-v1
Aug 7, 2026
Merged

feat(web): wire Studio Act on findings to durable video-to-actions (WDK v1)#1507
groupthinking merged 1 commit into
mainfrom
feat/workflow-video-to-actions-v1

Conversation

@groupthinking

Copy link
Copy Markdown
Owner

Canonical issue

Closes #1506

Outcome

Studio can Act on findings via a durable Workflow DevKit run: video URL → transcript → action agent, with runId start + status poll. Complements the existing FastAPI /api/pipeline + SSE path (still used for Deploy / Dashboard).

Scope

  • Included:
    • Harden video-to-actions steps to call fetchTranscript / runActionAgent directly (no self-HTTP loopback)
    • POST /api/workflows/video-to-actions edge host checks + statusUrl
    • GET /api/workflows/video-to-actions/:runId via getRun
    • Client helpers studio-workflow.ts + unit tests
    • Studio Act on findings button + action cards
    • AI rate-limit prefix for /api/workflows
    • docs/WORKFLOW_DEVKIT.md Product v1
  • Explicitly excluded: Durable Studio deploy (Option C), human approval hooks, Dashboard SSE rewrite

Risk

  • Risk level: medium (new product path + workflow runtime)
  • Failure mode: start() fails if workflow world misconfigured; Studio falls back to messaging + existing Dashboard/Deploy paths
  • Rollback: git revert; Studio button becomes a no-op path if API 500s

Verification

  • npx vitest run src/lib/__tests__/studio-workflow.test.ts src/lib/__tests__/studio-deploy.test.ts — 7 passed
  • Full test-frontend CI on PR
  • Vercel preview builds

Production evidence

Vercel preview on this branch. Runtime evidence requires provider keys + a real YouTube URL on preview; unit tests cover client start/poll contract.

Agent handoff

Land after CI green. Next product step: Option C (durable Studio deploy kickoff/poll) or stream step progress via getWritable().

…DK v1)

Product path for Option B: start/poll Workflow DevKit runs from Studio,
harden steps to call transcription + action-agent libs directly (no
self-HTTP loopback), and document the dual pipeline vs FastAPI SSE path.

Closes #1506
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v0-uvai Ready Ready Preview, v0 Aug 7, 2026 9:15pm

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are limited based on label configuration.

🏷️ Required labels (at least one) (1)
  • [‘architecture-gap’, ‘bug’, ‘ci-cd’, ‘ci/cd’, ‘copilot-rabbit’, ‘documentation’, ‘duplicate’, ‘enhancement’, ‘frontend’, ‘github_actions’, ‘good first issue’, ‘help wanted’, ‘high-priority’, ‘invalid’, ‘javascript’, ‘ml-model’, ‘needs-triage’, ‘pipeline-critical’, ‘placeholder-code’, ‘priority:high’, ‘python’, ‘python:uv’, ‘question’, ‘styling’, ‘tests’, ‘v0’]

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6ce8a856-a3bb-43b9-814a-f294f7f5d092

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread apps/web/src/proxy.ts
'/api/training',
'/api/transcribe',
'/api/video',
'/api/workflows',

@vercel vercel Bot Aug 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding /api/workflows to AI_ROUTE_PREFIXES puts the frequently-polled GET status endpoint under the strict AI rate limit (12/min), so the client poller gets 429'd before most runs finish and the UI is stuck on "still running".

Fix on Vercel

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed against the branch head (afa703b) — this is a real defect, and the numbers make it deterministic rather than a corner case.

The arithmetic

  • proxy.ts:30AI_LIMIT = 12 per WINDOW_SECONDS = 60.
  • proxy.ts:124-125isAiRoute is a pure prefix match on the pathname; it never looks at request.method. So /api/workflows covers the POST start and the GET /api/workflows/video-to-actions/:runId status endpoint identically.
  • studio-workflow.ts:140-141 — the poller defaults to attempts = 20, delayMs = 1500, i.e. one request every 1.5s ≈ 40 req/min sustained.

40 req/min against a 12/min bucket means the poller exhausts the window at roughly poll #12, ~18s in — and it is configured to keep going to poll #20 (~30s). Polls 13–20 are 429s on every run. The POST start draws from the same bucket, so it's actually poll #11.

Why it will read as a hang rather than an error

pollVideoToActions falls through to Still ${last.runStatus || 'running'} after ${attempts} polls (studio-workflow.ts:167). A 429 that isn't distinguished from "not terminal yet" lands in exactly that branch, so the UI reports still running for a request that was refused. The PR's own stated fallback behaviour masks the failure — which is what makes this worth fixing before merge rather than after.

Suggested shape

Splitting on method is the smallest change that keeps the intent: the expensive thing here is starting a run, not reading its status. Either gate the AI limit on non-idempotent methods, or list the start path explicitly (/api/workflows/video-to-actions for POST) and let the status GET fall through to GENERAL_LIMIT.

Worth noting GENERAL_LIMIT defaults to 60/min, so a 40 req/min poller only has ~33% headroom there — a second concurrent run in the same tab would breach it. Raising delayMs to ~2500ms (24 req/min) alongside the method split would leave the poll cadence comfortably inside the general bucket without changing the ~30s wall-clock budget materially.

Not pushing a fix here: this branch isn't mine to write to, and the change is small enough that it belongs in the author's next commit.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for confirming — agreed the numbers make this deterministic. The suggestion I attached takes the method-split approach you outline (only the POST start stays AI-classified; the GET status poll falls through to GENERAL_LIMIT).

Your headroom point is worth acting on too: at GENERAL_LIMIT=60/min, the default poller (attempts:20 delayMs:1500 ≈ 40/min) leaves only ~33% margin, so a second concurrent run in the same tab/IP would breach it. Raising delayMs to ~2500ms (≈24/min) as a companion change keeps the poll cadence comfortably inside the general bucket without materially changing the ~30s wall-clock budget. I'd treat the method-split as the fix and the interval bump as defense-in-depth.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in follow-up #1524/api/workflows is now classified by request method, so the GET status poll uses the 60/min general budget while only the POST start keeps the 12/min AI budget; added a regression test asserting the poll's X-RateLimit-Limit is 60. Not marking this thread resolved since #1507 already merged.

🤖 Addressed by Claude Code

Copy link
Copy Markdown
Owner Author

Red-team pass — this PR has had no review at all

Flagging first: CodeRabbit reported Review skipped: excluded by label configuration on this head. This PR carries no labels, and the inherited required-label gate (the one #1508 is chasing) means the only review it has received is this one. That matters more here than on the other open PRs — 595 lines, a new public API surface, and the description's own risk rating is medium.

Three findings. One I'd treat as blocking.


1. The new edge SSRF check blocks almost nothing — and reintroduces the exact bracket bug #1486 is fixing

route.ts hand-rolls a hostname denylist:

host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' ||
host === '::1' || host.endsWith('.local') || host.endsWith('.internal')

Measured by running that predicate over new URL(u).hostname.toLowerCase(), not by reading it:

URL Verdict
http://169.254.169.254/latest/meta-data ALLOW ← cloud metadata
http://10.0.0.1/x ALLOW
http://192.168.1.1/x ALLOW
http://127.0.0.2/x ALLOW — only .1 is listed
http://[::1]/x ALLOW — hostname is "[::1]", brackets included
http://[0:0:0:0:0:ffff:7f00:1]/x ALLOW
http://2130706433/x BLOCK — incidentally, the URL parser normalises it to 127.0.0.1

The host === '::1' comparison is dead code for the same reason #1486 exists one PR over: URL.hostname keeps the brackets on an IPv6 literal. That PR spent two commits and a CodeRabbit round establishing this, and it's re-landing here in a new file.

The fix is to not write this check. assertPublicHttpUrl in apps/web/src/lib/ssrf-guard.ts already does range checks over RFC1918, loopback, link-local, CGNAT, IPv6 literals in every spelling, and (as of #1486's 24be5e3) the NAT64/6to4 transition prefixes. Replacing the block with a call to it is strictly less code and strictly more coverage.

Severity, stated accurately: both routes are session-gated — /api/workflows is absent from PUBLIC_API_PREFIXES and PUBLIC_API_EXACT in auth-paths.ts, so needsAuthentication returns true when the gate is enforcing. This is authenticated-only, which is why I'd call it blocking-on-principle rather than urgent: the comment in the diff advertises it as SSRF defense-in-depth, and as written it provides approximately none of that while reading as though it does.

2. The status route leaks internal error detail to the client (CWE-209)

return NextResponse.json({
  ok: false, runId,
  error: message,   // raw err.message from the workflow world
  hint: 'Ensure the workflow package is installed and withWorkflow wraps next.config.',
}, { status: 500 });

This is the class the repo just spent #1381 and #1428 closing — collapsing client-visible rejection detail onto a constant and moving the cause to the server logs. The hint additionally discloses build configuration. The console.error above it is already the right channel; the response body should be a fixed string.

3. Minor: uncleared timer in the failed branch

new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 500))

If run.returnValue wins the race, the timer still fires. Promise.race has a handler attached so there's no unhandled rejection, but the pending timer keeps the event loop alive up to 500 ms after the response is ready. Capture the handle and clearTimeout in a finally.


Not findings, for the record

  • The self-HTTP loopback removal in video-to-actions.ts is a real improvement — dropping the NEXTAUTH_URL/VERCEL_URL base-URL guess removes a production failure mode, and the FatalError vs retryable split reads correctly.
  • The str() narrowing in studio-workflow.ts and the fail-closed ok: Boolean(payload.ok) && response.ok && Boolean(runId) are both right.
  • /api/workflows added to AI_ROUTE_PREFIXES is the correct prefix list for this surface.

CI

Not a useful signal on this head yet, and won't be shortly: every Actions job across the recent batch is sitting queued with none starting — the runner pool is saturated (#1510/#1511 are both aimed at that, which is its own problem). The three green statuses here are Vercel and the CodeRabbit skip, not tests. test-frontend landed on main in #1480, so once the queue drains it will actually run the four new studio-workflow cases.

Terminal state from my side: HALTED(review_findings_open) — finding 1 wants a change before merge.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Review — scheduled remediation routine, head afa703b

Four findings, none blocking a decision to proceed, one worth fixing before merge. I chased an SSRF hypothesis here and it did not hold up — details below, because the reason it doesn't hold is load-bearing and isn't written down anywhere in this diff.

1. The ::1 check in the edge guard is dead code — it can never match

const host = new URL(url).hostname.toLowerCase();
if (host === '::1' || )

URL.hostname keeps the brackets on an IPv6 literal. Measured on this head:

$ node -e "console.log(JSON.stringify(new URL('http://[::1]/').hostname))"
"[::1]"

So host === '::1' is never true. This is the same bug #1486 exists to fix in ssrf-guard.ts, arriving in a second file while that PR is still open. Fix is one line — .replace(/^\[|\]$/g, '') before the comparisons.

2. The edge guard re-implements assertPublicHttpUrl, and the comment justifying it names a backstop that doesn't apply here

// Reject obviously private/local targets at the edge (SSRF defense-in-depth;
// assertPublicHttpUrl still runs inside transcription when audioUrl is used).

The parenthetical is true in isolation and misleading in place. assertPublicHttpUrl is called at transcription-service.ts:274, inside if (audioUrl && process.env.OPENAI_API_KEY). This workflow calls fetchTranscript({ url }) and never sets audioUrl, so the guard never runs on this path. A reader is told there's a second line of defense that isn't in the circuit.

What the hand-rolled list lets through, run against it directly:

ALLOWED  "169.254.169.254"   ← cloud metadata (IMDS)
ALLOWED  "10.0.0.5"          ALLOWED  "192.168.1.1"
ALLOWED  "172.16.0.9"        ALLOWED  "100.64.0.1"   (CGNAT)
ALLOWED  "127.0.0.2"         ← only the exact .1 is listed
ALLOWED  "[::1]"             ← finding 1

assertPublicHttpUrl blocks every one of these, plus DNS names that resolve to private space, which no string denylist can catch.

Why this is not an exploitable SSRF today, and the reason took some digging: url reaches exactly two places, and neither fetches it.

  • fetchYouTubeMetadata(url) runs extractVideoId first, which is constrained to [a-zA-Z0-9_-]{11}, then fetches https://www.youtube.com/watch?v=<id> — a fixed host. A non-YouTube URL returns null before any fetch.
  • The backend POST sends url as the video_url field to a trusted BACKEND_URL; models.py:20 applies an anchored YouTube-host allowlist to it, added for precisely this ("unvalidated video_url → SSRF + CWE-88").

So the actual backstop is the Python allowlist, one service away — not the TypeScript guard the comment cites. That's worth stating correctly, because the next person to add a step to this workflow will read that comment and reasonably conclude the URL is already guarded on this side.

Suggested: replace the block with await assertPublicHttpUrl(url) (the route is already runtime = 'nodejs', so node:dns is available) and delete the denylist. That's strictly stronger, deletes code, and means #1486's hardening applies here automatically instead of drifting. If you'd rather keep it cheap and DNS-free, that's defensible — but then fix the comment to name the backend allowlist as the real control.

3. Raw internal error text and a config hint are returned to callers — [runId]/route.ts

return NextResponse.json({ ok: false, runId, error: message,
  hint: 'Ensure the workflow package is installed and withWorkflow wraps next.config.' },
  { status: 500 });

message is an unfiltered Error.message from the workflow world, and the hint describes internal deployment config. This repo has spent several PRs removing exactly this class of disclosure — #1381's collapse of the four DNS outcomes onto one constant, and the deliberate status suppression in the transcribe route right next door. Log err server-side (already done on the line above) and return a generic message. The runStatus === 'failed' branch has the same issue via payload.error = err.message.

4. Timer leak in the failed branch

await Promise.race([run.returnValue,
  new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 500))]);

Promise.race settles on the first result but does not cancel the loser, so when returnValue resolves first the 500 ms timer stays pending and keeps the function alive to fire it. Minor in absolute terms, but this is a poll endpoint — it's on the hot path. Capture the handle and clearTimeout in a finally.

Smaller notes

  • videoTitle is bounded to 200 chars but url is unbounded before being handed to start(). Cheap to cap.
  • No ownership check on the poll route: any authenticated caller holding a runId reads that run's result. Fine if runId is unguessable, worth a comment saying so.

Verification I did and didn't do

  • ✅ Findings 1 and 2 measured by execution, not inspection — the hostname bracket result and the denylist table above are real output.
  • ✅ Traced url through fetchTranscriptfetchYouTubeMetadata / backend POST, and read the backend validator, to establish the SSRF is not reachable.
  • Did not run the test suite. node_modules isn't installed in this sandbox and the Actions queue is saturated (see fix(ci): add concurrency groups so the Actions queue can drain #1510), so the 7 passing tests claimed in the description are unverified by me.
  • ❌ Required CI has not run on this head — every check is queued, not failing.

Terminal state for this PR in the remediation run: HALTED(ci_queued). Findings 1 and 3 are the ones I'd fix before merge; 2 is a judgement call I'd argue for but won't insist on.


Generated by Claude Code

@groupthinking
groupthinking merged commit 5135f4e into main Aug 7, 2026
25 of 26 checks passed
@groupthinking
groupthinking deleted the feat/workflow-video-to-actions-v1 branch August 7, 2026 21:22
@linear-code

linear-code Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

GRV-397

Copy link
Copy Markdown
Owner Author

Confirming the rate-limit finding, with the numbers — and it's wider than the status poller

Automated PR-remediation sweep. The Vercel Agent review thread on proxy.ts:41 is correct, and I traced it rather than taking it on faith. Two things it understates.

1. The poller exceeds the budget by ~3.3× on its own

isAiRoute is method-agnostic — it prefix-matches and nothing else:

function isAiRoute(pathname: string): boolean {
  return AI_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
}
function getRateLimit(pathname: string): number {
  return isAiRoute(pathname) ? AI_LIMIT : GENERAL_LIMIT;
}

So GET /api/workflows/video-to-actions/:runId lands on AI_LIMIT, which defaults to 12/min (UVAI_AI_RATE_LIMIT_PER_MINUTE || 12).

Against that, pollVideoToActions in studio-workflow.ts defaults to attempts = 20, delayMs = 1500 — a poll every 1.5s, i.e. 40 requests/min. The initial POST start shares the same bucket, so a single run issues 21 AI-class requests inside its ~30s window.

The 12th request is the one that 429s, which lands roughly 17 seconds into a 30-second poll window — before the run has any realistic chance of finishing, since the workflow does a transcript fetch plus an action-agent call. The UI reaches its Still running after 20 polls branch on essentially every run, so the feature reads as permanently broken rather than slow.

2. The blast radius is every other AI route, not just this one

This is the part worth flagging beyond the original comment. The bucket key is class-scoped, not path-scoped:

const routeClass = isAiRoute(pathname) ? 'ai' : 'api';
const key = `${routeClass}:${clientIp}`;

So all AI_ROUTE_PREFIXES share one ai:<ip> counter. One Studio "Act on findings" run doesn't just exhaust its own allowance — it drains the shared budget that /api/chat, /api/transcribe, /api/pipeline, /api/extract-events and /api/agents/dispatch draw from, for the remainder of the 60s window. A user who clicks the new button then finds chat and transcription 429ing too.

Suggested fix — make the AI class method-aware

The POST genuinely is AI-class (it kicks off transcript + agent work) and should stay metered. The GET status read does no model work and shouldn't be. Prefix matching alone can't separate them, so the method has to reach the classifier:

// Status polls are cheap reads that do no model work, but they share the
// `ai:<ip>` bucket with /api/chat and /api/transcribe. One Studio run polls
// 20x at 1.5s, which exhausts a 12/min budget in ~17s and takes the other
// AI routes down with it. Only the mutating call is AI-class.
const AI_ROUTE_METHOD_EXEMPT: Record<string, ReadonlySet<string>> = {
  '/api/workflows': new Set(['GET', 'HEAD']),
};

function isAiRoute(pathname: string, method: string): boolean {
  return AI_ROUTE_PREFIXES.some(
    (prefix) =>
      pathname.startsWith(prefix) && !AI_ROUTE_METHOD_EXEMPT[prefix]?.has(method),
  );
}

with getRateLimit(pathname, method) and both call sites in checkRateLimit passing request.method. The GET then falls to GENERAL_LIMIT (60/min), which still bounds a runaway poller but comfortably clears 40/min.

Worth a test pinning it, since nothing currently asserts the classification: isAiRoute('/api/workflows/video-to-actions/abc', 'GET') === false and … 'POST') === true.

Cheaper alternative if you'd rather not touch the classifier: raise delayMs to 5000 and drop attempts to 6. That fits 12/min but gives up responsiveness and still spends the whole shared AI budget on one run — I'd take the method-aware version.

Terminal state

HALTED(review_findings_open) — one unresolved actionable finding, and required checks have not concluded on afa703b yet. No changes pushed to this branch; this session is scoped to a different branch, so the patch above is staged here rather than committed.

On CI: the Actions queue is deep (251 runs queued, 22 in progress at the time of writing) and latency from run creation to conclusion is running ~20 minutes, so checks on this head should conclude rather than hang. Contrary to a note left on #1486, runs are concluding — 29 of the last 30 CI workflow runs concluded success, the most recent at 21:21:43 UTC. The queue is slow, not stalled. #1510 is the change that would drain it.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Red-team pass on the diff (PR-remediation sweep)

CI is still fully queued on afa703b — every Actions job has sat queued since 21:13, so there is no green/red to read yet. Reviewing the diff rather than waiting.

The workflow refactor is a clear improvement: dropping the NEXTAUTH_URL/VERCEL_URL self-HTTP fallback removes a loopback that would have re-entered the rate limiter and the auth gate from inside a step, and the FatalError vs retryable split now keys off the message instead of swallowing failures into a second attempt. Three findings on the new surface.


1. The edge SSRF check allows every RFC1918 literal and the metadata address — measured

route.ts adds a hand-rolled host check labelled "SSRF defense-in-depth". Driving that exact predicate:

input new URL(u).hostname verdict
http://localhost/ localhost BLOCKED ✅
http://127.0.0.1/ 127.0.0.1 BLOCKED ✅
http://169.254.169.254/ 169.254.169.254 ALLOWED
http://10.0.0.1/ 10.0.0.1 ALLOWED
http://192.168.1.1/ 192.168.1.1 ALLOWED
http://[::1]/ [::1] ALLOWED
http://[0:0:0:0:0:ffff:7f00:1]/ [::ffff:7f00:1] ALLOWED

Two separate problems. The list omits 10/8, 172.16/12, 192.168/16, 169.254/16 and CGNAT entirely. And host === '::1' is dead code: URL.hostname keeps the brackets on an IPv6 literal, so the comparison can never match — which is precisely the bug #1486 is open to fix in ssrf-guard.ts, re-introduced here in a new file while that fix is still in flight.

apps/web/src/lib/ssrf-guard.ts already exports assertPublicHttpUrl, which handles all of the above plus NAT64/6to4/IPv4-translated encodings. The fix is to call it rather than to extend this list:

import { assertPublicHttpUrl } from '@/lib/ssrf-guard';
// ...
try {
  await assertPublicHttpUrl(url);
} catch {
  return NextResponse.json({ error: 'url host is not allowed' }, { status: 400 });
}

Note the bare catch — do not interpolate the guard's message into the response; that is the CWE-209 oracle #1381 closed on /api/transcribe.

Severity is bounded, deliberately. The comment is right that assertPublicHttpUrl runs inside transcription, so this is a second line rather than the only one. But a check that names SSRF in its own comment and then passes 169.254.169.254 is worse than no check — it reads as coverage that is not there.

2. GET /:runId returns internal error text and a config hint to the caller

error: message,
hint: 'Ensure the workflow package is installed and withWorkflow wraps next.config.',

plus payload.error = err.message on both the completed and failed branches, where the message originates in a workflow step — so transcription-service and upstream-provider text reaches the response body. main spent two PRs (#1381, #1428) removing exactly this from /api/transcribe. Log the cause, return a constant.

This is authenticated-only — /api/workflows is not in PUBLIC_API_EXACT/PUBLIC_API_PREFIXES, so needsAuthentication gates it whenever NEXTAUTH_SECRET is set — which is why I am not calling it high. It is still the pattern this repo has now twice decided against.

3. runId has no ownership binding

getRun(runId) is called with a caller-supplied id and no check that the run belongs to the session. Any authenticated user who obtains or guesses a runId reads another user's resulttranscriptChars, provider, and the action list. The 200-char length cap validates shape, not authorisation. Worth either binding runId to the session at start() or stating explicitly why cross-user reads are acceptable here.


Smaller

  • pollVideoToActions defaults to 20 × 1500 ms and the Studio caller passes 24 × 2000 ms ≈ 48 s of foreground polling with no abort on unmount; the component has no cleanup for it.
  • maxDuration = 60 is on the POST route, which returns as soon as start() resolves. Harmless, but it is the [runId] GET route that has no maxDuration and does the awaiting.
  • The runStatus === 'failed' branch races run.returnValue against a 500 ms timer. If the world resolves slower than that under load, the real failure is replaced by the generic 'Workflow run failed'. A longer budget or reading the failure directly would be more reliable.

Not done

Terminal state: HALTED(ci_pending + unresolved_findings) — finding 1 should be closed before merge.


Generated by Claude Code

@github-actions github-actions Bot added documentation Improvements or additions to documentation javascript Pull requests that update javascript code tests labels Aug 7, 2026

Copy link
Copy Markdown
Owner Author

Finding 1 fixed and pushed — 8b72b16

The reason I gave for not pushing it is gone: #1486 merged at 21:23:02, so ssrf-guard.ts is settled on main and importing it no longer couples this PR to an in-flight one. Merged main into the branch and replaced the inline list with the shared guard.

try {
  await assertPublicHttpUrl(url);
} catch {
  return NextResponse.json({ error: 'url host is not allowed' }, { status: 400 });
}

The catch is bare on purpose. Interpolating the guard's message would re-open the CWE-209 oracle #1381 closed on /api/transcribe — "does not resolve" and "resolves to a private address" have to read identically to the caller. The guard already logs the real cause.

Non-vacuity, by mutation

New file apps/web/src/app/api/__tests__/workflows-video-to-actions-route.test.ts, 12 cases. I restored the old hand-rolled check and re-ran:

× rejects http://169.254.169.254/ (cloud metadata) without starting a run
× rejects http://10.0.0.1/ (RFC1918 10/8) without starting a run
× rejects http://192.168.1.1/ (RFC1918 192.168/16) without starting a run
× rejects http://172.16.31.9/ (RFC1918 172.16/12) without starting a run
× rejects http://[::1]/ (bracketed IPv6 loopback) without starting a run
× rejects http://[0:0:0:0:0:ffff:7f00:1]/ (IPv4-mapped IPv6 loopback) …
× rejects http://[64:ff9b::a9fe:a9fe]/ (NAT64-encoded cloud metadata) …
× rejects a hostname that resolves to a private address
  Tests  8 failed | 4 passed (12)

Eight kills, and they are exactly the eight the old list let through. The four survivors are the two it did handle (127.0.0.1, localhost), the indistinguishability assertion, and the public-host control — the control is what stops the whole file being satisfiable by rejecting everything.

One assertion worth calling out: each rejection case also asserts start was not called. A refused URL that still kicked off a durable run would not prevent the fetch, only relocate it somewhere harder to observe.

Verification on 8b72b16

  • npx vitest run (apps/web) — 60 files, 361 passed, 0 failed
  • npx tsc --noEmit — clean
  • npm run lint — clean

Still open, deliberately not pushed

Finding 2 — the status route returns err.message plus hint: 'Ensure the workflow package is installed and withWorkflow wraps next.config.', and sets payload.error from step errors on both the completed and failed branches. Same CWE-209 pattern, authenticated-only. It is a response-shape change on a contract your client helper parses, so it is yours to make rather than mine to impose mid-review.

Finding 3getRun(runId) has no ownership binding, so any authenticated user who obtains a runId reads another user's result. That is a design question (are runs per-user?), not a defect with an obvious patch.

The smaller notes from the earlier comment stand: the 48 s foreground poll has no unmount cleanup, maxDuration is on the POST rather than the awaiting GET, and the 500 ms race on the failed branch can replace a real failure with the generic string.

CI

Still not a readable signal. Every Actions job on this PR has been queued since 21:13 — nothing started, nothing failed. #1510 (concurrency groups) is the fix for that and is itself fully queued, which is the deadlock worth knowing about before reading any check state on this batch.

Terminal state: HALTED(awaiting_ci) — finding 1 is closed; 2 and 3 are yours to rule on.


Generated by Claude Code

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Snapshot Warnings

⚠️: No snapshots were found for the head SHA afa703b.
Ensure that dependencies are being submitted on PR branches and consider enabling retry-on-snapshot-warnings. See the documentation for more information and troubleshooting advice.

Scanned Files

None

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

🔍 PR Validation

⚠️ Large PR detected (651 lines changed)

groupthinking added a commit that referenced this pull request Aug 13, 2026
…t bucket (#1518)

* fix(web): stop workflow status polls draining the shared AI rate-limit bucket

#1507 added /api/workflows to AI_ROUTE_PREFIXES. isAiRoute classified by path
prefix alone, so the polled GET status endpoint was metered against the AI
budget (default 12/min) while pollVideoToActions polled at 40/min. The 12th
request 429'd ~17s into a 30s window, before a transcript fetch plus an agent
call could finish.

The bucket is keyed by class, not path, so every AI prefix shares one ai:<ip>
counter -- a single Studio run also 429'd /api/chat, /api/transcribe and
/api/pipeline as collateral.

Move the classifier into auth-paths.ts, which exists as the home for path
policy free of Next.js request types so vitest can import it offline, and make
it method-aware. GET/HEAD on /api/workflows falls to the general budget; POST
stays AI-class because starting a run does real model work. The exemption is
keyed per-prefix rather than exempting GET globally, so it cannot widen another
route that later serves model work over GET. An omitted method defaults to POST
so the failure mode is the stricter limit.

Also retune the poller to 30 attempts x 2s: 30 req/min leaves roughly half the
general allowance for the rest of the page, and the wall-clock window doubles to
60s, which better fits the work the run actually does.

Closes #1517

* fix(web): require a segment boundary before exempting a route from the AI budget

Self-review follow-up. The exemption looked the prefix up with the same loose
startsWith used for class membership, so a future sibling surface whose name
merely starts with an exempted prefix -- /api/workflows-admin -- would silently
inherit the GET carve-out and drop onto the looser budget.

No route in the tree does this today (checked every directory under
apps/web/src/app/api), so this is latent rather than live. It is worth closing
while the file is open: the same shape, an incidental block quietly becoming an
allow, is what #1486 had to fix in the SSRF guard.

Class membership keeps its original loose matching. Narrowing that would move
routes off the stricter budget, which this change has no business doing; the
exemption is the widening, so only the exemption is tightened.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation javascript Pull requests that update javascript code tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(web): wire Studio Act on findings to durable video-to-actions workflow (WDK Product v1)

1 participant